__author__ = "Marc Huisinga"

from collections import namedtuple
import math
import ipaddress
import argparse
import sys


UserSubnetInfo = namedtuple("UserSubnetInfo", "name expected_hosts subnet_count")
SubnetInfo = namedtuple("SubnetInfo", "name required_host_bits subnet_count")
SubnetData = namedtuple("SubnetData", "name network_address broadcast_address netmask hostmask")


def required_host_bits(hosts):
    """
    Calculates the required bits for the specified amount of hosts.
    :param hosts: amount of hosts to calculate the required host bits for
    :return: amount of required host bits
    """
    total_hosts = hosts + 2
    required_bits = math.ceil(math.log2(total_hosts))
    return required_bits


def is_subnettable_ipv4_network(argument):
    """
    Checks if the argument is a subnettable ipv4 network.
    :param argument: argument to check
    :return: returns subnettable ipv4 network if the argument is one
    :raise argparse.ArgumentTypeError: raised if the argument is not a subnettable ipv4 network
    """
    invalid_argument_error = argparse.ArgumentTypeError("%s needs to be a subnettable IPv4 network."
                                                        % argument)
    try:
        network = ipaddress.IPv4Network(address=argument)
    except (ValueError, ipaddress.AddressValueError, ipaddress.NetmaskValueError):
        raise invalid_argument_error
    if network.prefixlen >= network.max_prefixlen - required_host_bits(hosts=1):
        raise invalid_argument_error
    return network


def is_positive_int(argument):
    """
    Checks if the argument is a positive integer.
    :param argument: argument to check
    :return: returns converted argument if the argument is a positive integer
    :raise argparse.ArgumentTypeError: raised if the argument is not a positive integer
    """
    invalid_argument_error = argparse.ArgumentTypeError("%s needs to be a positive integer." % argument)
    try:
        value = int(argument)
    except ValueError:
        raise invalid_argument_error
    if value <= 0:
        raise invalid_argument_error
    return value


def is_user_subnet_info(argument):
    """
    Checks if the argument is a valid UserSubnetInfo.
    :param argument: argument to check
    :return: converted UserSubnetInfo if the argument is valid
    :raise argparse.ArgumentTypeError: raised if the argument is not a valid UserSubnetInfo
    """
    invalid_argument_error = argparse.ArgumentTypeError("%s needs to be a valid subnet_info." % argument)
    subnet_info_args = argument.split(",")
    args_len = len(subnet_info_args)
    subnet_info_len = len(UserSubnetInfo._fields)
    default_subnet_info_len = subnet_info_len - 1
    if args_len != subnet_info_len and args_len != default_subnet_info_len:
        raise invalid_argument_error
    name = subnet_info_args[0]
    try:
        expected_hosts = is_positive_int(subnet_info_args[1])
        subnet_count = is_positive_int(subnet_info_args[2]) if args_len == subnet_info_len else 1
    except argparse.ArgumentTypeError:
        raise invalid_argument_error
    return UserSubnetInfo(name, expected_hosts, subnet_count)


def parse_args(args):
    """
    Parses the arguments for vlsm_subnetter.
    :param args: arguments to parse
    :return: parsed arguments
    """
    parser = argparse.ArgumentParser(description="Script to subnet a network using VLSM")
    parser.add_argument_group()
    parser.add_argument("network_address",
                        type=is_subnettable_ipv4_network,
                        help="CIDR IPv4 address of the network to subnet.")
    parser.add_argument("subnet_info",
                        type=is_user_subnet_info,
                        nargs="+",
                        help="Information of the respective subnet. "
                             "Format: 'name,expected_hosts[,subnet_count]'")
    parser.add_argument("-o", "--outputfile",
                        type=argparse.FileType("w"),
                        help="Output location of the file where the information "
                             "of the subnetted network is supposed to be saved.")
    args = parser.parse_args(args)
    return args


def invalid_subnet_info_terminator(network, subnet_name):
    """
    Prints a message indicating that the specified subnet info is invalid
    and terminates the script.
    :param network: specified network which the subnet is invalid in
    :param subnet_name: name of the subnet that is invalid
    """
    print("Subnet '%s' does not fit into %s." % (subnet_name, network.exploded))
    sys.exit()


def build_subnets(network, subnet_info, first_network):
    """
    Builds the subnets for the specified subnet information.
    :param network: network to subnet
    :param subnet_info: information to build the subnets
    :param first_network: network to start using the subnets at
    :return: built subnets,
             next available network after the last built subnet or None if there is none
    """
    new_prefixlen = network.max_prefixlen - subnet_info.required_host_bits
    subnet_name = subnet_info.name
    if new_prefixlen > network.prefixlen:
        all_subnets = network.subnets(new_prefix=new_prefixlen)
    else:
        invalid_subnet_info_terminator(network, subnet_name)
    first_subnet = next((subnet for i, subnet in enumerate(all_subnets)
                        if subnet.network_address == first_network.network_address))
    leftover_subnets = subnet_info.subnet_count - 1
    try:
        subnets = [first_subnet] + [next(all_subnets) for _ in range(leftover_subnets)]
    except StopIteration:
        invalid_subnet_info_terminator(network, subnet_name)
    try:
        next_network = next(all_subnets)
        return subnets, next_network
    except StopIteration:
        return subnets, None


def extract_subnet_data(subnet_name, subnets):
    """
    Extracts subnet data from the specified subnets.
    :param subnet_name: name of the subnets
    :param subnets: subnets to extract the data of
    :return: subnet data
    """
    subnet_data = (SubnetData(subnet_name if len(subnets) == 1 else subnet_name + str(subnet_index),
                              subnet.exploded,
                              subnet.broadcast_address.exploded,
                              subnet.netmask.exploded,
                              subnet.hostmask.exploded)
                   for subnet_index, subnet in enumerate(subnets))
    return subnet_data


def subnet_network(network, subnet_infos):
    """
    Subnets a network.
    :param network: network to subnet
    :param subnet_infos: information to create the subnets
    :return: addresses of the created subnets
    """
    next_network = network
    for subnet_info in subnet_infos:
        first_network = next_network
        subnets, next_network = build_subnets(network, subnet_info, first_network)
        subnet_data = extract_subnet_data(subnet_info.name, subnets)
        for subnet_datum in subnet_data:
            yield subnet_datum


def build_subnetted_network(network, user_subnet_infos):
    """
    Builds a fully subnetted network.
    :param network: network to subnet
    :param user_subnet_infos: informations to build the subnet with
    :return: subnetted networks
    """
    user_subnet_infos.sort(key=lambda elem: elem.expected_hosts, reverse=True)
    subnet_infos = (SubnetInfo(subnet_info.name,
                               required_host_bits(subnet_info.expected_hosts),
                               subnet_info.subnet_count)
                    for subnet_info in user_subnet_infos)
    subnet_data = subnet_network(network, subnet_infos)
    return subnet_data


def write_subnet_data_to_file(subnet_data, output_file):
    """
    Writes the subnet data to a file.
    :param subnet_data: subnet data to write
    :param output_file: file to write to
    """
    subnet_data_fields = SubnetData._fields
    column_names = "%s\n" % ",".join(subnet_data_fields)
    output_file.write(column_names)
    last_subnet_field_index = len(subnet_data_fields) - 1
    for subnet_datum in subnet_data:
        for field_index, field in enumerate(subnet_datum):
            column_elem = "%s," % field if field_index != last_subnet_field_index else field
            output_file.write(column_elem)
        output_file.write("\n")
    output_file.close()


def print_subnet_data(subnet_data):
    """
    Prints the subnet data.
    :param subnet_data: subnet data to print
    """
    for subnet_datum in subnet_data:
        subnet_datum = SubnetData._asdict(subnet_datum)
        for subnet_key in subnet_datum:
            print("%s: %s" % (subnet_key, subnet_datum[subnet_key]))
        print()


def start_vlsm_subnetter(args=sys.argv[1:]):
    """
    Starts the vlsm_subnetter with the specified arguments.
    :param args: arguments to pass to the vlsm_subnetter
    """
    try:
        arguments = parse_args(args)
        subnet_data = list(build_subnetted_network(network=arguments.network_address,
                                                   user_subnet_infos=arguments.subnet_info))
        output_file = arguments.outputfile
        if output_file:
            write_subnet_data_to_file(subnet_data, output_file)
        else:
            print()
            print_subnet_data(subnet_data)
        print("Subnetting successfull.")
    except KeyboardInterrupt:
        print("Interrupted.")


if __name__ == "__main__":
    start_vlsm_subnetter()